denoのhttp serverが新しくなりました
先々週末にdeno_stdのhttpモジュールの再設計を行ってたkeroxp.icon2019/2/19 deno_stdのhttpモジュールはstdの中でも最古(2ヶ月前くらい)に書かれたコードベース
自分がdenoを触り始めた昨年末、その最たるきっかけになったのがこのhttpモジュール
「deno? どうせまだ動か……なんだコイツ!」となったのが、httpのREADMEにあったsampleだった
その後二ヶ月の間にこのhttpモジュールを使ったライブラリが生まれて盛り上がり見せた
個人的にdenoのhttpサーバで新しさを感じたのはAsyncIterableIteratorでリクエストを処理するところ
code:ts
for await (const req of serve("0.0.0.0:8080")) {
req.respond({
body: new TextEncoder().encode("OK")
})
}
こういうコードになっていて興味を惹かれた
Node.js時代、httpリクエストはserver.on("request", req => {...})というイベント駆動だった
DenoではEventEmitterは推奨されていない
async/awaitが基本
でも実際、非同期IOを行う場合はイベントっぽい書き方が必要になる
歴史を振り返ると、どうやらstdのhttpモジュールはRyan自身が書いたコードだったのだが、 知らなかった…keroxp.icon
こういう場面でAsyncIterableIterator(Promiseを返すGenerator)とfor await構文が使われていてへ〜となった
のだけど、自分はこのasync iterationがベストなスタイルだとは思わなくなった
なぜかというと、iteratorをawaitしているときそのasync関数はブロックしてしまうからだ
また、AsyncIteratorはそのままだとgeneratorをawaitしているときそれをcancelする手段がない
一度iterationを始めたasync iteratorを止めたいという場面はあった
例えばテスト
このacceptRoutineを外部から止める手段が今までなかった
新しいhttpモジュールのserveはこうなります
code:ts
const cancel = defer()
setTimeout(cancel.resolve, 10 * 1000) // 10秒後にserveを止める
(async function () {
for await (const {req, res} of serve("0.0.0.0:8080", cancel.promise) {
await res.resond({
status: 200,
headers: new Headers({
"Content-Type": req.headers.get("Content-Type")
}),
body: req.body
})
}
})()
いくつかのポイントが有るので解説
1: Deferred APIの追加
defer()でcancellerを作ります
deferは、手動でresolve/reject可能なpromiseとresolver/rejectorを返してくれます
code:ts
export type Deferred<T = any, R = Error> = {
promise: Promise<T>;
resolve: (t?: T) => void;
reject: (r?: R) => void;
readonly handled: boolean;
};
serveの第二引数がそのcancellerのpromiseを受け取れるようになりました
serveが返すasync iteratorは、毎回cancellerとPromise.raceで争われ、cancellerがresolveされた場合このfor awaitを安全にbreakします
このcancellerはオプショナルで、なにも渡さないと常にlistener.accept()が勝つようになります
テストなどではsetupでサーバーを立てて、teardownでcancellerを解決するなどができるようになりました
2: serveのイテレーション型の変更、ServerRequest, ServerResponderの分離
serveのイテレータの型が変わりました
もともとServerRequestのasync iteratorでしたが、こうなりました
code:ts
export async function* serve(
addr: string,
cancel: Deferred = defer()
): AsyncIterableIterator<{ req: ServerRequest; res: ServerResponder }>
どこかで見慣れたこの2つの組み合わせになりました
code:ts
for await (const {req, res} of serve("0.0.0.0:8080")) {
}
こうやってデストラクチャするのがいい感じ
またいままでServerRequestオブジェクトがリクエストとレスポンダを兼任していましたが、この変更で以下の2つに分離されました
code:ts
export type ServerRequest = {
url: string;
method: string;
proto: string;
headers: Headers;
match: RegExpMatchArray;
body: Reader;
};
export interface ServerResponder {
respond(response: ServerResponse): Promise<void>;
respondJson(obj: any, headers?: Headers): Promise<void>;
respondText(text: string, headers?: Headers): Promise<void>;
readonly isResponded: boolean;
}
reqはimmutableなリクエストオブジェクト
resはレスポンスを返すためのオブジェクトという感じ
この変更の一番の理由はServerRequestを非class化したかったから
ServerRequestがclassのままだとポリモーフィックにかけない部分が増えてくるのでた
ついでにrespondJson()とrespondText()も追加した
No More new TextEncoder()
また、ユーザがrespondしなかった場合もクライアントに500を返すようになりました
今までの仕様だと永遠にレスポンス返ってこなかった
HttpServerの新設
これが一番の大きな変更です
上記のような理由もあり、serveはhttp serverの最下層のAPIという感じだった
のだがもう少し中間層のServer APIがあってもいいかなと思っていた
のでHttpServerというAPIを追加した
見たほうが早い
code:http_server.ts
// サーバー作成
const server = createServer();
// ルート登録(文字列)
server.handle("/", async (req, res) => {
await res.respondText("Hello Deno!");
});
// ルート登録(正規表現)
server.handle(new RegExp("/foo/(?<id>.+)"), async (req, res) => {
// 名前付きキャプチャからparmsを取得(後述)
const { id } = req.match.groups;
await res.respondJson({ id });
});
// 起動
server.listen("127.0.0.1:8080");
1: createServer
HttpServerはInterfaceで、createServerはそのファクトリです。実態はクラスですが。
code:ts
export interface HttpServer {
handle(pattern: string | RegExp, handler: HttpHandler);
listen(addr: string, cancel?: Deferred): Promise<void>;
}
http.handleFuncとか
ServerMux.Handle()は、文字列しか引数に取れずprefix matchしかしてくれないのだが、自分はRegExpも渡せていいんじゃないかと思いこうデザインした
ry曰く、
I would very happily have a ServeMux class here that implemented the above routing logic.
そういう(prefix matchだけ)ルーティングロジックを実装したServerMuxがあるといいんだけどなー
keroxp.icon曰く、
I don't think that it is much complicated...routing with string or regex can be used for general purpose. serve() should be considered as the lowest http server api.
これそんなに複雑じゃないと思うんだけど…文字列と正規表現でルーティングするの、一般的用途に使えると思う。serve()こそ最下層のAPIとして使われるべきでは?
これについては少しRyanに妥協してもらう形になったかもしれない😅
最初はExpressスタイルのpath-to-regexpをそのまま入れようとしたのだが、ES2018の正規表現を使うとPure ESでこういうのができる code:ts
server.handle(new RegExp("/users/(?<id>\d+)", (req, res) => {
const {id} = req.match.groups;
res.respondJson({id})
})
Expressでは慣れた操作だけど、RegExpだけでできるのはちょっと面白い
ServerRequestのmatchプロパティはreq.url.match(pattern)(意訳)の返り値になっている
ので、名前付きキャプチャをしなくても正規表現的な抜き出しが可能
これ地味にシンプルで強力な実装だと思う
これでDenoがhttpサーバーを立てるのに一番簡単な選択肢に躍り出てきたんじゃないかと密かに思ってます
JS/TSで書ける
標準ライブラリに最低限+αのHTTPサーバーAPIがある
パッケージマネージャいらない
babelいらない。最新ES仕様に対応
Node.jsユーザなら見慣れたサーブスタイル
ただまだDeno本体は全然開発途上で、やっぱりいまDenoで何かを作るのはオススメしません
今回の変更も結構なbreak cahngeなので…
今回の変更でserve()の内部ロジックも変えたんだけど、検証の結果遅くなってしまったのでrevertされたり…
Deno側の最適化、ts側の最適化がまだまだ不十分だし、Httpサーバーとしての機能もまだまだ不完全です
Http2未対応
TLS未対応
Keep-Alive未対応(?)
gzip未対応
Cookie未対応
Trailerヘッダ未対応
その他HTTP/1.1の隠し仕様を誰も把握してない可能性あり
でもこれからどんどん対応してくと思う
Denoのこれからにご期待下さい